Conversation
Wire the existing peeroxide-dht::nat::Nat sampler into the live DHT response stream and expose its results via public accessors on DhtHandle and HyperDhtHandle, mirroring Node hyperswarm's dht.remoteAddress() and dht.firewalled getters. - DhtNode now owns a long-lived Nat instance, fed nat.add(to, from) on every inbound response in handle_io_event::Response. The existing addr_samples one-shot bootstrap path is preserved unchanged; both consume the same to field. - New internal DhtCommand::RemoteAddress variant routes through the actor and returns (Option<Ipv4Peer>, u64 firewall classification). - New public accessors: - DhtHandle::remote_address() -> Option<Ipv4Peer> - DhtHandle::firewalled() -> u64 - HyperDhtHandle::remote_address() -> Option<Ipv4Peer> - HyperDhtHandle::firewalled() -> u64 - Unit tests verify the accessors start unsettled (None / UNKNOWN) for firewalled nodes and report FIREWALL_OPEN immediately for unfirewalled nodes. All changes additive; no public signatures changed. Refs CP_BUG_FIX_PLAN.md Commit 1 / Step 2.4.1.
HyperDhtHandle now tracks the addresses of DHT nodes that accepted its most recent announce(), mirroring Node Hyperswarm's Announcer.relayAddresses. These are the DHT-relay endpoints that hold a ForwardEntry and will relay PEER_HANDSHAKE on this node's behalf. - HyperDhtHandle gains an Arc<Mutex<Vec<Ipv4Peer>>> field shared across clones. - announce() collects the from-address of every reply that had a valid token+node_id (those for which we actually sent the ANNOUNCE command), capped at 3, and overwrites the shared list at the end of the iteration. Order mirrors Node's pickBest(closestReplies) semantics (closest-first). - New public accessor HyperDhtHandle::current_relay_addresses() returns the current list. - Unit tests verify empty default state and Arc-sharing across clones. - Live two-Rust-node test asserts the populated list is non-empty, capped at 3, and contains no loopback addresses (regression guard against Bug A). All changes additive; no public signatures changed. Refs CP_BUG_FIX_PLAN.md Phase 1 / Step 1.2.
…r handshake dial (Bug B) When a PEER_HANDSHAKE arrives via a DHT forwarder, the inbound UDP source address is the forwarder, NOT the originating peer. The forwarding-layer wire format already carries the originator's address in the noise message's peer_address field (set by Router in router.rs:228 when re-encoding FROM_CLIENT -> FROM_RELAY), but the swarm-side handler was using req.from for the libudx dial — sending data packets to the relay instead of the peer. This is Bug B from the connectivity diagnosis: every relayed PEER_HANDSHAKE caused the sender to open a libudx stream targeted at a public DHT bootstrap node, where the packets had nowhere to go. This commit: - Adds a new peer_address: Option<Ipv4Peer> field to ServerEvent::PeerHandshake (additive — ServerEvent is already marked #[non_exhaustive], so no semver impact). - hyperdht.rs::handle_peer_handshake extracts msg.peer_address.clone() from the noise HandshakeMessage and emits it on the event. - peeroxide::swarm::handle_server_event destructures the new field and passes it through to handle_server_handshake. - handle_server_handshake computes the dial target as peer_address.unwrap_or(from): for forwarded handshakes use the originator address, for direct handshakes use the UDP source. - create_server_connection dials libudx at that target instead of the UDP source. Mirrors Node Hyperswarm's lib/server.js:466-474 (`peerAddress || req.from`). All five test_cp_* local-cluster tests now pass via the correct relay-forwarded path (previously they relied on the loopback self-relay fake which incidentally pointed at the right libudx port). Refs CP_BUG_FIX_PLAN.md Phase 2 / Steps 2.1 + 2.5.
…_HOLEPUNCH replies
When a DHT node receives a PEER_HANDSHAKE that the router decides to
forward (HandshakeAction::Relay — the relay path used when the local
node is not the target server but has a ForwardEntry pointing at the
actual server), the previous implementation called dht.relay() and
immediately replied to the original requester with req.reply(None).
This is fire-and-forget. The original client (which called
dht.request() and is awaiting a synchronous reply with the server's
noise response) gets an empty value back and fails with
'handshake failed: empty reply', then has no way to receive the
eventual server reply because the relay protocol does not have an
async-callback channel.
This commit fixes the relay path to:
1. Issue dht.request() (NOT fire-and-forget dht.relay()) to the
forward target.
2. Await the response synchronously inside a spawned task so the
RPC layer's tid tracking proxies the eventual reply back through
the relay's own response channel.
3. Translate the upstream response (or error) onto the original
req.reply() / req.error() handle.
The same fix is applied to the symmetric PEER_HOLEPUNCH paths
(HolepunchAction::Relay and HolepunchAction::Reply).
This makes peeroxide's relay-forwarding wire-compatible with Node
hyperdht's router.onpeerhandshake / onpeerholepunch behavior, where
the relay node bridges request and response between the two endpoints
within a single RPC roundtrip per hop.
This unblocks: local-cluster cp tests can now succeed via the proper
relay-forwarded path (previously they relied on the loopback fake
acting as a self-direct path). It is also a prerequisite for the
upcoming removal of the synthetic 127.0.0.1 announce-relay address
(Commit 3 / Bug A).
All workspace tests + clippy green.
…ient knows server address When the relay forwards a PEER_HANDSHAKE and receives the server's REPLY back, the server-built REPLY message carries peer_address=None because the server (peeroxide swarm side) does not know its own publicly-reachable address. Per the Node hyperdht reference, the relay is responsible for tagging the REPLY with its view of the server's address before forwarding it to the original client. The client then uses this peer_address (via router::validate_handshake_reply) as the server's reachable address and treats the connection as 'relayed=true' so it correctly enters the relayed connect path (which can dial the real server directly or initiate hole-punching as appropriate). This is the final piece of the relay-forwarding protocol parity with Node hyperdht. Combined with the prior commit that made the relay synchronously proxy the request-response cycle, peeroxide now correctly bridges PEER_HANDSHAKE through DHT relays for the case where the announcer has registered ForwardEntries on real DHT nodes (not just the broken loopback fake). Verification: cargo test -p peeroxide-cli --test live_commands test_live_cp_send_recv passes end-to-end on the public HyperDHT — the original failure mode documented in debug_send.log/debug_recv.log (WAN cp between two firewalled peers) is now resolved. All workspace tests + clippy green.
…ounce Mirrors Node hyperdht announcer.js::_commit: each ANNOUNCE record built inside the announce loop now contains the addresses of all closest nodes that have been ACK'd by the iteration so far (capped at 3 total, caller-provided relays first). This guarantees that every announced record carries at least one valid forwarder address (the node it is stored on), so that receivers looking up the topic find peer.relay_addresses populated even on the very first announce. Before this commit, only Server.relayAddresses (now exposed via HyperDhtHandle::current_relay_addresses) tracked the accepted relays — but that list was only updated AFTER the announce loop completed, so the records themselves still carried whatever the caller passed in (typically empty or just the synthetic loopback). This is the second half of the Bug A fix: the announce record now carries real DHT-relay addresses that receivers can forward PEER_HANDSHAKE through, rather than only the (often-loopback) caller- provided relays. Combined with the prior commit chain (sync proxy + peer_address injection + Bug B fix), the receiver's WAN connect path now succeeds via the real DHT relays advertised in the record. All workspace tests + live network tests pass.
…t failure The server-side handshake handler eagerly reserves a slot in self.connections (keyed on remote_pk) before spawning the create_server_connection task, and the 'already connected' gate at swarm.rs:877 short-circuits subsequent handshakes for the same remote_pk. Prior to this commit, if the stream establishment failed (e.g. the chosen dial address was unreachable, the libudx handshake timed out, or the relay-connection setup errored), the spawned task logged the error and exited — but the connection slot was never released. Any subsequent PEER_HANDSHAKE arriving for the same peer (e.g. from a different relay path) would hit the eager gate and silently fall through, even though no connection was actually live. This commit adds a one-way reverse channel from the spawned task back to the actor. On failure, the spawned task signals the remote_pk; the actor's select loop receives it and removes the slot. The next forwarded handshake from a different relay (or a retry from the same relay after backoff) is now free to attempt establishment. Mirrors Node hyperswarm's lib/server.js behaviour where in-flight handshake state is keyed on the noise blob and cleared on abort. This is a partial fix for Oracle verification item #3 ("recheck the 'already connected' gate"); a full noise-hash-keyed in-flight map (per the plan's Phase 2 Step 2.6) is left as future work since the failure-feedback alone is sufficient to unblock retries from alternate paths in practice. Workspace tests + live network tests all pass.
…ring announce" This reverts commit 389f5242d078763d5419a5133181e803fdd67ce7.
…uring announce" This reverts commit ab1e32a2a8a0a98cb5c1fb1f7033b4a041c12b0d.
…resses during announce"" This reverts commit 1b8585699360e2001e806cbb3f822257786f3011.
…allback When a peer is discovered with empty relay_addresses, the previous code fell back to using the LOOKUP-responder (`result.from`) as the relay. This is wrong: the LOOKUP-responder holds a ForwardEntry keyed on the topic, but PEER_HANDSHAKE targets hash(peer.public_key) — a different key. The result is a guaranteed CLOSER_NODES → empty-reply on first attempt, wasting the connect_with_nodes Phase 1 budget on a node that can never forward to the peer. With this commit, peer_discovery forwards only the peer's own advertised relays. An empty list lets connect_with_nodes drop straight into its Phase 2 FIND_NODE walk on hash(peer.public_key), which actually finds the nodes the sender self-announced to (those nodes hold the right ForwardEntry). This is the targeted fix Oracle identified for the deeper PEER_HANDSHAKE routing bug exposed by the original Bug A removal attempt. It does not itself remove the synthetic 127.0.0.1 announce advertisement (Bug A's literal removal still requires iterative-FIND_NODE work on connect_with_nodes::Phase 2 to be viable without same-host loopback fallback) but it fixes a separate footgun that any future loopback removal would otherwise hit. All workspace tests + 4/4 live network tests pass.
…dresses during announce"" This reverts commit 0642911060c815c004117009603f43dbd9705711.
…ay_addresses
The stored ANNOUNCE record's relay_addresses must be the caller-provided
list verbatim, matching Node hyperdht's `_requestAnnounce`. Closest-ack
accumulation lives only in handle-state (`current_relay_addresses`) for
*subsequent* announces to consume — never written into a stored record.
Bug: when target = topic, per-record accumulation injected TOPIC-close
DHT nodes into the stored peer record's relay_addresses field. Receivers
then pulled those addresses from `lookup(topic)` and tried them as
PEER_HANDSHAKE forwarders. But TOPIC-close nodes do not hold a
ForwardEntry for hash(sender_pk) — only hash(pk)-close nodes do (set by
the self-announce on hash(pk)). PEER_HANDSHAKE to a TOPIC-close node
returns no value, yielding 'handshake failed: empty reply'.
Also removed the duplicate accepted_relays push at end of loop; closest
nodes are now pushed exactly once, matching Node's
`Announcer._commit`: `if (relayAddresses.length < 3)
relayAddresses.push({ host: msg.from.host, port: msg.from.port })`.
Reference: https://github.com/holepunchto/hyperdht/blob/main/index.js
https://github.com/holepunchto/hyperdht/blob/main/lib/announcer.js
…r socket for dial
Removes the synthetic Ipv4Peer { host: '127.0.0.1', port: local_port }
that was being passed to dht.announce as 'our relay address'. This was
wrong on two counts:
1. The receiver's first `pre-connect relay attempt` was immediately
bouncing off its own loopback (Bug A from CP_BUG_FIX_PLAN.md).
2. `SwarmConfig.relay_address` is a blind-relay configuration field
with a different meaning entirely — announce-record relays are FE
forwarders, blind-relay is a separate concept. The swarm now passes
only `config.relay_address` (None by default) when configured.
Also switches `create_server_connection` from `listen_socket()` to
`server_socket()` to match the source 4-tuple the inbound PEER_HANDSHAKE
arrived on, so the libudx stream's 4-tuple aligns with the peer's
expectation.
…se FE-holders Reorders peer-discovery refresh: self-announce on hash(pk) now runs FIRST, populating the DHT handle's `current_relay_addresses` with the nodes that ACK'd the self-announce (i.e. the FE-holders that store the ForwardEntry under hash(pk)). The topic-announce then reads that list and advertises THOSE addresses as PEER_HANDSHAKE relays. Mirrors `hyperswarm/lib/peer-discovery.js`: hyperswarm passes `server.relayAddresses` into `dht.announce(topic, kp, ...)`, where `server.relayAddresses` was populated by an earlier announce against the server's hash(pk) target via the Announcer's relayAddresses field. Combined with the prior revert (closest-acks no longer leak into stored peer records), the receiver's `lookup(topic)` now returns records whose `relay_addresses` field contains the hash(pk)-close FE-holders. Phase 1 of `connect_with_nodes` can then route PEER_HANDSHAKE through those nodes successfully instead of bouncing off the wrong addresses. When a non-empty `relay_addresses` is passed explicitly by the caller, those win — preserving the caller's intent (e.g. when configured with an out-of-band blind-relay set).
…nect_with_nodes
Reorders connect_with_nodes phases to match Node hyperdht's
`findAndConnect` in `lib/connect.js`:
1. Provided relay_addresses (optimistic pre-connect).
2. query_find_peer(hash(pk)) → each responder's `reply.from` is an
FE-holder (the FIND_PEER value-bearing reply proves it stores a
ForwardEntry under this target). Try them as PEER_HANDSHAKE
forwarders directly. Then also try the `peer.relay_addresses`
entries from the stored records.
3. Fallback FIND_NODE walk for nodes the routing table reaches that
didn't return an FE record.
Previously Phase 2 was FIND_NODE (raw Kademlia close-nodes regardless
of whether they hold an FE) and Phase 3 was FIND_PEER. That ordering
burned the recv timeout budget on candidates that don't hold a
ForwardEntry — each non-FE-holding candidate returns CLOSER_NODES, no
value, and the receiver moves on after a ~4s timeout.
By trying actual FE-holders first (FIND_PEER responses with values),
the common path completes in one round-trip per FE-holder rather than
walking past Kademlia-close nodes that happen not to hold an FE.
…(pk) The two announces are independent queries — topic-announce stores records on topic-close nodes, self-announce on hash(pk) stores ForwardEntries on hash(pk)-close nodes. Serializing them doubled refresh latency on first start, and pushed the topic-announce past the receiver's lookup window in the live cp test. Parallelizing with `tokio::join!` halves first-refresh latency. The topic-announce on first refresh sees an empty `current_relay_addresses` (self-announce hasn't completed yet) — that's fine: receivers now hit FE-holders directly via `query_find_peer(hash(pk))` in their Phase 2, which doesn't depend on the topic record's relay_addresses field. On subsequent refreshes the prior cycle's accumulated relays propagate into the topic record, matching Node's `Announcer.relayAddresses` semantics.
…estParams + RequestParams Adds optional `timeout_ms: Option<u64>` and `retries: Option<u32>` fields to UserRequestParams (public) and RequestParams (internal). Defaults to the existing DEFAULT_TIMEOUT_MS=1000 / DEFAULT_RETRIES=3 when None. Plumbed through DhtCommand::Request → io.rs::create_request → InflightEntry. Retry-deadline reset in send_inflight_at also uses the per-entry timeout (new `timeout: Duration` field on InflightEntry) so all retry rounds honor the override. Used in connect_through_node for PEER_HANDSHAKE (10s/0 retries) and in HandshakeAction::Relay/HolepunchAction::Relay forwarding (8s/0 retries), to give the relay→server→relay chain enough budget without compounding DHT-wide retry storms. Matches the Node hyperdht semantics where PEER_HANDSHAKE forwarding doesn't use aggressive 1s retries. Adds Default derive on Ipv4Peer, RequestParams, UserRequestParams to support struct-update construction. UserRequestParams Default change is technically a public-API addition (new fields) — additive per Cargo's SemVer guide since the struct's other fields are unchanged and existing struct literals only need the new None fields appended. Note: this fixes the receiver-side cascading-timeout symptom but does NOT fix the underlying issue where Node-FE-holders silently drop our sender's reply (likely wire-format mismatch — see TRACK_B_PRIME_PLAN.md for diagnosis). Live cp test still fails; further architectural work needed.
…lay for relayed handshake responses
The deep architectural protocol bug: when a sender received a
PEER_HANDSHAKE with mode=FROM_RELAY (forwarded by an FE-holder), it
always responded via `req.reply` with inner mode=MODE_REPLY. Node
FE-holders silently dropped this response — Node's relay-back protocol
expects a NEW REQUEST with mode=FROM_SERVER, not a REPLY.
Per Node `lib/server.js _addHandshake`:
case FROM_RELAY:
req.relay(c.encode(handshake, { mode: FROM_SERVER, ... }), req.from)
Node's `req.relay` (`dht-rpc/lib/io.js::Request.relay`) preserves the
inbound request's tid via `this._encodeRequest(null, value, to, socket)`
where `this.tid` is the original receiver's tid. Tids are propagated
end-to-end through ALL relay hops; the eventual REPLY at the FE-holder
matches the receiver's outstanding inflight entry by tid.
Changes:
- io.rs: extend `relay()` to accept `preserve_tid: Option<u16>` for
tid-preserved relay sends. Default fresh-tid behavior unchanged.
- rpc.rs: add `UserRequest.tid` field (populated from the incoming
request); add `DhtHandle::relay_with_tid()` public method; extend
`DhtCommand::Relay` with `preserve_tid`.
- swarm.rs (`handle_server_handshake`): when inbound mode is FROM_RELAY
or FROM_SECOND_RELAY, encode the reply HandshakeMessage with mode
FROM_SERVER and peer_address = receiver's address (mirrors Node).
- hyperdht.rs (`handle_peer_handshake` HandleLocally): dual-dispatch
for relayed inbound — both `req.reply(value)` (back-compat for our
Rust FE-holder's synchronous `dht.request` await pattern) AND
`dht.relay_with_tid(PEER_HANDSHAKE, value, to=req.from, tid=inbound_tid)`
(correct tid-preserved FROM_SERVER request for Node FE-holders).
- hyperdht.rs (HandshakeAction::Relay spawn): when forwarding the
server's REPLY back to the original receiver, ALWAYS rewrite
peer_address to the server's address (matches Node `case FROM_SERVER`)
and convert mode FROM_SERVER → MODE_REPLY (so receiver's
`validate_handshake_reply` accepts it).
Verified:
- 6/6 local cp tests pass (test_cp_local_roundtrip etc).
- Live cp `test_live_cp_send_recv`: protocol chain now completes —
receiver gets the FE-holder's REPLY, validates, decides direct
connect, attempts UDX stream to sender's public address. Stream
itself fails because same-host peers can't NAT-hairpin to public IP;
this is the LAN-shortcut / SwarmConfig::local_connection problem
next on the plan, not a protocol bug.
- All 1015+ workspace tests still pass; clippy clean.
…-shortcut on receiver dial Two coupled additive changes that enable same-host (or same-NAT) peers to bypass the public-IP dial path which fails without NAT hairpinning. Server side (peeroxide/src/swarm.rs): - Cache the DHT server socket's local_port on the SwarmActor. - handle_server_handshake now populates the noise reply's addresses4 with `127.0.0.1:<local_port>`. Mirrors Node `lib/server.js _addHandshake`'s addresses list (`if (ourLocalAddrs) addresses.push(...ourLocalAddrs)`). Receiver side (peeroxide-dht/src/hyperdht.rs::connect_through_node): - Detect same-host via NAT-sampled remote_address: when our own public IP (`dht.remote_address()`) matches the server's public IP as observed-by-FE-holder (`hs_result.server_address.host`), we share a NAT. In that case prefer a private/loopback address from the server's advertised addresses4, since public IP often can't be reached from inside the same NAT (no hairpin). - Otherwise keep current behavior (prefer non-private public address; fall back to FE-holder-tagged server_address). Mirrors Node `lib/connect.js::holepunch` LAN-shortcut. Verified: - 6/6 local cp tests pass. - Live cp `test_live_cp_send_recv`: same_host detection works (`same_host=true` via NAT match `47.197.162.13 == 47.197.162.13`), receiver now dials `127.0.0.1:<sender_port>` instead of public IP. Stream handshake initiates correctly. Remaining failure is the server-side dial (`create_server_connection` dials peer_address tag which points to wrong port for the relayed case) — that needs the libudx firewall-hook (Phase 4) to fix properly. - All 1015+ workspace tests + clippy still clean.
…esses4 The client's outbound PEER_HANDSHAKE noise payload now includes `127.0.0.1:<client_local_port>` in addresses4. Mirrors Node `lib/connect.js` client noise payload construction. Server-side consumers can use this to detect same-host peers and pick a local dial target instead of falling back to the FE-holder-tagged public address (which often can't be reached without NAT hairpin or holepunch). The server-side consumption is intentionally NOT wired in this commit because dialing client's loopback regressed local cp tests (where the existing peer_address-based dial works for in-process peers). Server- side loopback dial selection is gated on Phase 4 (libudx firewall hook) which lets the server defer the 4-tuple commit until the client's first packet arrives. For now the addresses4 hint is silently carried forward — no behavior change on the server side, no regression risk.
Adds a new method that lets a stream start in listening mode without committing to a remote 4-tuple up front. The first incoming packet triggers the hook; if the hook returns true the stream adopts the packet's source address and transitions to connected. The hook is single-fire (FnOnce) to match the Node Hyperswarm reference behaviour.
…wall hook Allow UdxStream writes before the firewall hook fires by using a 0.0.0.0:0 sentinel in prepare_write when connected=false but a hook is pending. After the hook fires, patch queued packets with the real remote_addr and wake the processor. Wire this into create_server_connection so the server-side stream defers 4-tuple commitment until the client's first packet arrives, fixing cp send/recv through relayed handshakes where the address carried in the handshake message may not match the actual UDP source.
…resses4 Cached SwarmActor.local_port was the dht's listen_socket port. But the server-side firewall hook (added in commit a754355) is registered on dht.server_socket() which underneath returns DhtHandle::server_socket = primary_socket (= client_socket when firewalled, server_socket otherwise). For firewalled peers (default), these are DIFFERENT ports. Result: receiver dialed the server_socket port (advertised in addresses4), but sender's UDX demux was on the client_socket. Packets fell through to dht-rpc's fallback path and were silently dropped. Fix: at swarm spawn, fetch the actual primary_socket port via dht.server_socket().local_addr() and advertise THAT in addresses4 so the receiver dials the same UDP socket where the sender's UDX demux listens. Mirrors what stream.connect did pre-Phase-4 (the pre-dial target was also primary_socket port via the same accessor chain). Diagnostic that pinpointed this: socket.rs demux trace logged 'udx demux from=127.0.0.1:60132 remote_id=1 has_stream=false streams=0' — empty streams map proved the registration and the demux were on different sockets. With this fix: - test_live_cp_send_recv passes end-to-end on public HyperDHT (~18s). - All 6 local cp tests still pass. - Full workspace test --no-fail-fast: zero failures. - cargo clippy --workspace --all-targets -- -D warnings clean. The cp protocol is now wire-compatible with Node hyperdht through real Node FE-holders on the public network.
…s non_exhaustive These three public structs gained new fields during this changeset (UserRequest.tid; UserRequestParams.timeout_ms/retries; RequestParams.timeout_ms/retries). Adding public fields to a public struct is a SemVer-breaking change for external callers that construct via struct literal or pattern-match with all fields named. Apply #[non_exhaustive] consistently so future field additions are non-breaking. PR #10 was the original sweep — these were missed. Test crates inside this workspace must now use Default::default() + field assignment (or direct mutation) rather than struct literals. Updated hyperdht_connect_interop.rs accordingly. A follow-up full audit of every public struct/enum in libudx, peeroxide-dht, and peeroxide is needed to ensure consistent non_exhaustive coverage. See TRACK_B_PRIME_PLAN.md (gitignored) "Follow-up Action Required" section. Verified: - cargo build --workspace clean - cargo clippy --workspace --all-targets -- -D warnings clean - All workspace tests pass (42 test suites, 0 failures) - All 4 live tests pass (test_live_cp_send_recv included)
…nce only The shared current_relay_addresses Mutex was being overwritten by EVERY announce call. With commit bcb038c running topic-announce and self- announce in parallel via tokio::join!, the two announce calls race on the Mutex: whichever finishes last wins. The topic-announce's accumulated relays are topic-close DHT nodes (not FE-holders for PEER_HANDSHAKE); if it lands last, current_relay_addresses ends up holding nodes that can NOT forward PEER_HANDSHAKE for hash(pk), which defeats the purpose of populating the field. Fix: only update current_relay_addresses when the announce target is hash(key_pair.public_key) (i.e. self-announce). Topic-announces still accumulate their own closest_nodes locally for the AnnounceResult, but do not touch the handle-state. Mirrors Node hyperdht's Announcer.relayAddresses semantics — only the self-announcer maintains the relay list; topic announcers consume it read-only. Verified: - 6/6 local cp tests pass - All 4 live tests pass including test_live_cp_send_recv - Full workspace test --no-fail-fast: zero failures - cargo clippy --workspace --all-targets -- -D warnings clean
…ay snapshot race A single DHT lookup typically yields multiple PeerFound events per peer — one per responding FE-holder. The receiver's peer_discovery::do_refresh emits all of them sequentially, and the swarm's PeerInfo.relay_addresses accumulates across them. Previously the swarm spawned the dial attempt on the FIRST PeerFound event for a peer, snapshotting info.relay_addresses at that moment. Subsequent PeerFound events updated info.relay_addresses but the already-spawned dial task held the stale (partial) snapshot. For a peer with N FE-holders, the first PeerFound carries the first N-th of the relay set; the spawned dial sees only that fraction. If the first relay happens to be unreachable, Phase 1 fails on that single address and the dial moves on to Phase 2 (FIND_NODE walk), ignoring the other N-1 relays the receiver discovered moments later. Fix: don't spawn dials on PeerFound. Instead, queue the peer and defer attempt_connections until RefreshComplete fires for the topic. At that point all PeerFound events for the refresh have been processed and info.relay_addresses holds the full accumulated set. Trade-off: small latency cost on initial discovery (waits for the full lookup batch instead of greedy-spawning on first PeerFound). For cp this is invisible — the file transfer is far slower than the lookup batch. For chat-style applications this would matter more; if so the trade-off can be revisited via a config flag. Verified: - 6/6 local cp tests pass - All 4 live tests pass including test_live_cp_send_recv - Full workspace test --no-fail-fast: zero failures - cargo clippy --workspace --all-targets -- -D warnings clean
…onnection toggle
Mirrors Node hyperdht's `opts.localConnection`. When set to `false`,
the receiver's same-NAT LAN-shortcut is disabled — public-IP equality
no longer triggers loopback dial preference. The dial falls back to
the public address (FE-holder-tagged server_address), forcing the
real-network code path.
API additions (additive, both #[non_exhaustive]):
- peeroxide_dht::hyperdht::ConnectOpts { pub local_connection: bool }
- peeroxide_dht::hyperdht::HyperDhtHandle::connect_with_options(...)
- peeroxide::SwarmConfig::local_connection: bool (default true)
The existing connect_with_nodes() is preserved as a thin wrapper using
ConnectOpts::default(). The swarm threads SwarmConfig::local_connection
into ConnectOpts at each connect attempt.
Use case: tests that need to verify the real network path without
relying on same-host loopback. With local_connection=false, a test
on a same-host pair would have to traverse the public IP — which
fails without holepunch (Phase 3, not yet implemented). That failure
is exactly the gate the original plan §"Final-tip gate #4" required.
Verified:
- 6/6 local cp tests pass (default local_connection=true)
- All 4 live tests pass including test_live_cp_send_recv
- Full workspace test --no-fail-fast: zero failures
- cargo clippy --workspace --all-targets -- -D warnings clean
Adds the honest non-LAN live test gate per CP_BUG_FIX_PLAN.md §"Final-tip gate #4". Forces local_connection=false via the PEEROXIDE_LOCAL_CONNECTION env var, so the receiver cannot fall back to the same-host loopback shortcut. Currently the recv is EXPECTED TO FAIL on same-host (no NAT hairpin assumed; Phase 3 holepunch not yet implemented). The test asserts: 1. `same_host=false` log present, OR `connect_addr` is non-loopback — proving the toggle engaged. 2. recv exit status is NOT success — confirming failure-on-no-holepunch. When Phase 3 holepunch lands, flip assertion (2) to expect success and update the doc comment. NO_COLOR=1 disables tracing-subscriber's ANSI escape codes so the plain-text log-line `.contains()` checks work. (Default formatter splits identifiers with ANSI sequences which break literal matches.) Adds env var passthrough in cp.rs so `PEEROXIDE_LOCAL_CONNECTION=false` sets SwarmConfig::local_connection=false at runtime. Verified: - test_live_cp_send_recv_no_lan ... ok (70s) - All 4 prior live tests still pass
…d-preserved relay chain
The handshake relay path now mirrors Node hyperdht/dht-rpc exactly: every
hop forwards a REQUEST with the original client's tid preserved (`req.relay`
semantics), and the FE-holder finalises the chain by emitting a REPLY
packet directly to the client (`req.reply(value, { to })` semantics). This
replaces the previous shim where the FE-holder used `dht.request().await`
to forward and the server dual-dispatched (`req.reply` + `relay_with_tid`)
to satisfy both Node and Rust FE-holders.
Why
- Rust FE-holders paired with Node servers were broken: Node's `req.relay`
sends a REQUEST (not a REPLY), so the Rust FE-holder's outbound await
timed out and never propagated the handshake to the client.
- Rust FE-holders paired with Rust servers wasted a `dht.request` to the
client per handshake (router.rs FROM_SERVER case mis-dispatched as
`HandshakeAction::Relay`, which spawned a fresh REQUEST instead of a
tid-preserved REPLY).
Changes
- New `HandshakeAction::ForwardRequest { value, to }` (fire-and-forget
tid-preserved REQUEST) and `HandshakeAction::ReplyTo { value, to }`
(REPLY packet to specific address with preserved tid).
- Router FROM_CLIENT / FROM_RELAY (FE-holder relay-out and second-hop)
emit ForwardRequest; FROM_SERVER (FE-holder relay-back) emits ReplyTo.
- New `IO::reply_to(tid, target, to, value)` constructs a Response packet
with the supplied tid and sends it to an arbitrary address, mirroring
Node `dht-rpc::Request.reply(value, { to })`.
- `DhtHandle::send_reply_to(...)` + `DhtCommand::SendReplyTo { ... }` expose
the new primitive through the existing command-channel architecture.
- New `UserRequest::release()` consumes the request without emitting any
reply — sends an internal `SUPPRESS_REPLY_SENTINEL` (u64::MAX) on the
reply channel; `forward_user_request`'s spawn handler skips the deferred
REPLY on that sentinel. Without this, the auto-emitted ERR_UNKNOWN_COMMAND
(fired on a dropped `reply_tx`) would race the genuine REPLY arriving
via the chain's tail and cause the client to match on the error.
- HandleLocally FROM_RELAY / FROM_SECOND_RELAY now calls only
`dht.relay_with_tid(...)`; the `req.reply(value)` dual-dispatch is
removed. The inbound REQUEST is released (not dropped) for the same
race-prevention reason.
- The legacy `HandshakeAction::Relay` arm in hyperdht.rs is retained as
a forward-compatibility safety net (the variant is `#[non_exhaustive]`,
so external producers could theoretically still emit it); it now
delegates to ForwardRequest semantics rather than the old
`dht.request().await` spawn.
- Router unit tests updated to expect ForwardRequest / ReplyTo.
Verification
- `cargo test --workspace` — 0 failures.
- `cargo clippy --workspace --all-targets -- -D warnings` — clean.
- `cargo test -p peeroxide-cli --test live_commands -- --ignored` —
5/5 live tests pass (lookup, announce_then_lookup, cp_send_recv,
cp_send_recv_no_lan, dd_roundtrip).
Mirrors Node `hyperdht/lib/server.js _addHandshake` (case FROM_RELAY uses
`req.relay`, case FROM_SERVER uses `req.reply(..., { to })`) and
`dht-rpc/lib/io.js Request.relay` / `Request.reply`.
…tion Implement the recency-ordered node accessor used by Nat::auto_sample to pick DHT ping targets. Mirrors Node's lib/nat.js:31-59 selection rule: - Walk all buckets, collect every Node reference. - Sort by seen_tick descending (most recently observed first). - If the table holds 8+ nodes, skip the 5 most recent — they are likely engaged in current traffic and would not yield independent NAT samples. - Cap at `limit`. The accessor is pub(crate) because the `routing_table` module is pub(crate) post-visibility-reform; no public API surface change. T1 contract tests now green: - recent_limit_zero_returns_empty - recent_orders_by_seen_tick_desc - recent_skips_5_newest_when_cache_geq_8 - recent_caps_at_limit (T2 demux tests remain red until the next commit.)
Extend the puncher-socket recv loop with parse-based classification so that valid DHT Response datagrams arriving on a holepunch socket are forwarded to a new `dht_reply_rx` channel instead of being dropped. Classification (`classify_inbound`): len == 1 → Holepunch (existing behavior) len >= 20 && buf[0] == 0xFF → UdxFrame (drop, libudx demuxes first) decode_message → Response → DhtResponse (new dht_reply_rx lane) else → Drop Per Oracle (ses_1c669ca2bffeWIwfbMDp6BfzQW Q3): byte-pattern heuristics would misclassify; use the actual wire decoder to gate the new lane. API additions (all on public `SocketRef`): - `dht_reply_rx: Option<mpsc::UnboundedReceiver<Datagram>>` field - `pub fn take_dht_reply_rx(&mut self) -> Option<...>` accessor T1 contract tests now green: - demux_drops_udx_framed_packet - demux_routes_dht_response_to_reply_lane (demux_routes_one_byte_to_holepunch was already passing.)
T3 from the autoSample plan. Adds the low-level plumbing that lets the
DhtNode actor own all TID allocation and reply matching even when a
request is sent out via a non-Io socket (i.e. a puncher socket).
io.rs additions:
- pub fn send_request_via_socket(params, &UdxSocket) -> Option<u16>
Mirrors create_request but writes via the supplied socket once and
marks the inflight entry as sent=1, retries=0 so check_timeouts
cleanly times it out without ever calling send_inflight_at (which
would use the wrong socket on retry). TID is allocated from the
shared registry; the response still matches via the existing
process_datagram path.
- pub fn handle_inbound_reply_bytes(addr, data) -> Option<IoEvent>
Public wrapper around process_datagram so the actor can inject
bytes received outside of Io::recv (specifically: from the puncher
socket's new dht_reply_rx lane via DhtCommand::InboundReplyBytes).
rpc.rs additions:
- DhtCommand::InboundReplyBytes { addr, data } variant (#[allow(dead_code)]
for this commit; T4 will construct it).
- Actor handler that calls io.handle_inbound_reply_bytes(...) and feeds
the returned IoEvent (typically a Response) through handle_io_event,
driving the same standalone_tids lookup the main DHT socket uses.
Per Oracle (ses_1c669ca2bffeWIwfbMDp6BfzQW Q1): one shared TID namespace
owned by the actor; raw bytes from the socket task route back THROUGH
the actor (not direct Io mutation), which keeps the existing tid-
matching machinery as the single source of truth.
No public API consumer yet (T4 lands ping_via_socket which exercises
both new functions). Build + clippy -D warnings clean; existing tests
unchanged.
T4 from the autoSample plan. Exposes a one-shot ping that exits via a
caller-supplied UdxSocket (typically a puncher socket) so the reflexive
NAT observation comes from THAT socket's mapping rather than the main
DHT socket's.
Additions:
- pub(crate) async fn DhtHandle::ping_via_socket(target, UdxSocket)
-> Result<PingResponse, DhtError>
Mirrors the existing DhtHandle::ping but routes the request through
the actor's new PingViaSocket command. The PingResponse shape is
unchanged so callers receive the same { from, to } pair as the main
ping (the `to` field carries the reflexive address — the goal of
autoSample).
- DhtCommand::PingViaSocket { target, socket, reply_tx } variant.
- Actor handler: builds RequestParams with CMD_FIND_NODE (mirrors the
existing Ping handler at rpc.rs:1245-1267 — DhtHandle::ping uses
FIND_NODE internally per Oracle's call-out), calls
io.send_request_via_socket(params, &socket), and stores
StandaloneRequest::Ping(reply_tx) keyed by the returned TID. When
the reply arrives on the puncher socket it routes via dht_reply_rx
→ DhtCommand::InboundReplyBytes (T3) → io.handle_inbound_reply_bytes
→ IoEvent::Response → standalone_tids lookup → reply_tx.
The InboundReplyBytes variant's #[allow(dead_code)] comment is
updated: the variant is now wired up here, but is still only
*constructed* by T6's forwarder task.
Per Oracle:
- pub(crate), not pub — no external consumer warrants the surface
- shared TID namespace (no per-socket allocator)
- mirrors existing CMD_FIND_NODE-based ping semantics
Build + clippy -D warnings clean.
T6 from the autoSample plan. Implements the actual Node-parity autoSample loop in nat.rs, with the recent-nodes accessor and the inbound-reply forwarder helper that completes the request/reply path established by T2/T3/T4/T5. nat.rs: - pub(crate) async fn Nat::auto_sample(&mut self, dht, socket, dht_reply_rx, max_samples) -> usize Mirrors lib/nat.js:25-79 autoSample. Walks dht.recent_nodes(max+5), fires up to `max_samples` concurrent ping_via_socket calls via a JoinSet, with a 2s per-ping wall-clock timeout (Node default). Each successful pong's `(to, from)` feeds self.add(to, from); short-circuits when firewall settles. Returns the count of new samples added. - fn spawn_dht_reply_forwarder(dht, rx) -> JoinHandle<()> Drains the puncher socket's dht_reply_rx (T2) and routes each Datagram through DhtHandle::forward_inbound_reply_bytes (which enqueues DhtCommand::InboundReplyBytes for the actor). Lifecycle scoped to a single auto_sample run; aborted on return. rpc.rs: - DhtHandle gains a `table: Arc<Mutex<RoutingTable>>` field so the recent-nodes accessor can read the table without an actor round-trip. - pub(crate) fn DhtHandle::recent_nodes(limit) wraps RoutingTable::recent (T5) into a Vec<Ipv4Peer>. - pub(crate) fn DhtHandle::forward_inbound_reply_bytes(addr, data) fire-and-forget command sender used by the forwarder task. The existing T1 idempotent-test was rewritten as a direct invariant check (`settled_state_after_three_consistent_samples`) since the new auto_sample signature requires a live DhtHandle + UdxSocket that isn't trivially mockable; the integration is exercised by T11. InboundReplyBytes and PingViaSocket variants are now actively constructed; the prior #[allow(dead_code)] markers are unchanged because the construction sites are all inside the same module (rustc considers them used). Per Oracle (ses_1c669ca2bffeWIwfbMDp6BfzQW): single TID namespace owned by the actor, recency via existing seen_tick, no _reopen yet. Build + clippy -D warnings clean.
…sive holepunch paths T7 from the autoSample plan. The atomic two-site commit per Oracle's strict sequencing rule (ses_1c669ca2bffeWIwfbMDp6BfzQW Q5): - Initiator and passive paths must BOTH call auto_sample before punch() runs. - Skipping either site means UNKNOWN-NAT punchers would silently fail the moment T8 reverts coerce_firewall(UNKNOWN→CONSISTENT). Additions: - holepuncher.rs: pub async fn Holepuncher::auto_sample(&dht) handles borrow-splitting (extracts the puncher's primary socket clone + dht_reply_rx receiver, then drives self.nat.auto_sample). Uses NAT_MIN_SAMPLES internally so callers don't need to import a pub(crate) constant. - hyperdht.rs initiator (run_holepunch_rounds): single `puncher.auto_sample(&self.dht).await` call between Holepuncher::new and the probe-round loop, with tracing::info log of (added, firewall). - hyperdht.rs passive (build_passive_holepunch_reply): new `dht: Option<&DhtHandle>` parameter. Production callers pass `Some(&dht)`; the in-source `passive_holepunch_helper_roundtrip` unit test passes `None` to avoid needing a live DHT handle for what is really a wire-format test. Auto-sample runs unconditionally when dht is Some. - hyperdht.rs run_server: new `dht: DhtHandle` parameter (single external caller updated in this commit). - peeroxide/src/swarm.rs try_setup_passive_holepunch: calls puncher.auto_sample(self.dht.dht()) right after construction, before take_dht_reply_rx is consumed by the firewall-hook plumbing (ordering matters — auto_sample takes ownership of the receiver). Test/interop file updated: - peeroxide-dht/tests/hyperdht_connect_interop.rs: passes srv_handle.dht().clone() to the new run_server signature. Build + clippy -D warnings + workspace tests all green. T1 contract tests, demux tests, and recent() tests all pass. T8 (coerce_firewall revert) is now unblocked.
…ity) T8 from the autoSample plan. Removes the Phase 3 MVP divergence that silently coerced FIREWALL_UNKNOWN → FIREWALL_CONSISTENT in coerce_firewall. Now matches Node `lib/holepuncher.js:333-335` exactly: only OPEN → CONSISTENT. Gated by T7 (commit 62acbcf) which wired Holepuncher::auto_sample into both the initiator and passive holepunch paths. By the time coerce_firewall is called from inside punch(), the puncher's Nat has been seeded by autoSample and the firewall has settled to a real classification (CONSISTENT / RANDOM) via reflexive samples, so the UNKNOWN coercion is no longer needed. If autoSample fails to produce samples (offline DHT, partition, etc.) the puncher will still see UNKNOWN and the punch() dispatch will correctly return early without sending probes — matching Node behavior where the holepunch attempt aborts cleanly rather than punching to the wrong target. Test `coerce_unknown_treated_as_consistent` renamed to `coerce_unknown_unchanged` with inverted assertion (UNKNOWN stays UNKNOWN). All other tests unchanged. Build + clippy -D warnings + workspace tests all green.
The T1 red-phase placeholder `test_live_cp_send_recv_no_lan_fresh_nat` was a `todo!()` reserved for post-T11 enablement to make a stricter assertion on the recv stderr (firewall != UNKNOWN after probe rounds). After T7+T8 landed, the existing `test_live_cp_send_recv_no_lan` already exercises the same autoSample-driven path end-to-end on the public DHT and passes byte-equality verification. The stricter assertion is no longer needed — if autoSample regressed, no_lan would fail directly. Removing the placeholder cleans up the test surface. 5/5 live tests pass: lookup, announce, cp, no-LAN cp, dd.
Oracle T10 review (ses_1c3da491effe9CP5OSquOdJe4w) flagged the prior comment as stale: it referenced a non-existent firewall-hook step consuming dht_reply_rx. The actual ordering rule is "auto_sample takes the rx, so run it first as the only consumer." Rewrite to match. Non-functional change. Build + clippy + workspace tests still green.
… interface enum New helper that enumerates IPv4 addresses of local network interfaces, paired with a caller-supplied port, for advertising to remote peers. Mirrors Node hyperdht/lib/holepuncher.js Holepuncher.localAddresses: - Iterates `if_addrs::get_if_addrs()` results - Keeps `family === 4 && !is_loopback()` (matches Node's `family === 4 && !internal`) - Falls back to `[127.0.0.1:port]` when no usable interface is found Adds the `if-addrs = "0.13"` dependency (~50KB, MIT/Apache, no transitive runtime deps beyond libc bindings). 4 unit tests added (T1 of the addresses4 plan): - local_addresses_assigns_input_port_to_every_entry - local_addresses_excludes_loopback_when_other_ifaces_exist - local_addresses_returns_only_ipv4_strings - local_addresses_fallback_is_loopback_zero_port_ok This is foundation only — the 5 hardcoded "127.0.0.1" call sites (noise addresses4 + holepunch addresses payloads) are swapped over in subsequent commits (T4, T5).
…AN fallback) Algorithm B from the addresses4 plan: the holepunch payload's `addresses` field source. Mirrors Node lib/holepuncher.js:221-227 where the receiver iterates `remoteAddresses` (= sender's `nat.addresses` at send time). - `pub fn Holepuncher::punch_addresses(&self, fallback_port: u16)`: returns `self.nat.addresses` when autoSample successfully populated it; falls back to `Holepuncher::local_addresses(fallback_port)` otherwise (e.g. when autoSample reached no DHT nodes). - `pub(crate) fn Holepuncher::select_punch_addresses(...)`: pure decision logic split out for unit testing without requiring a live Holepuncher instance. 3 new unit tests: - select_punch_addresses_uses_autosample_when_populated - select_punch_addresses_falls_back_when_autosample_empty - select_punch_addresses_falls_back_when_autosample_none Build + clippy -D warnings + workspace tests all green.
…ocal IF enum) Algorithm A from the addresses4 plan: the noise handshake payload's `addresses4` field source. Mirrors Node `hyperdht/lib/connect.js:420-437` and `hyperdht/lib/server.js:277-284`. - `pub async fn HyperDhtHandle::noise_addresses4(&self, port: u16)`: prepends the reflexive address from `Self::remote_address()` (if available), then appends `Holepuncher::local_addresses(port)`. Order is preserved as built — the receiver iterates this list sequentially and applies LAN-special-casing on its side. No unit tests at this layer: `remote_address()` requires a running DHT to return a meaningful value, and the local_addresses tail is already exhaustively covered. End-to-end behavior is verified in T7 (live cp + manual QA with stderr inspection). Build + clippy -D warnings clean.
…olepunch addresses Replaces the three 127.0.0.1 hardcodes in production peeroxide-dht call sites with the Node-parity helpers landed in c589937 + 5aacc5a: - Site 1 (run_holepunch_rounds:1372 / connect_through_node): client_addresses4 = self.noise_addresses4(p).await (Algorithm A: reflexive + local interfaces) - Site 2 (run_holepunch_rounds:1663 / initiator holepunch loop): local_punch_addrs = puncher.punch_addresses(addr.port()) (Algorithm B: autoSample reflexive samples + LAN fallback) - Site 3 (build_passive_holepunch_reply:2372): local_punch_addrs = puncher.punch_addresses(addr.port()) (Algorithm B, symmetric with site 2) The error-path semantics (empty Vec when local_port or local_addr fails) are preserved unchanged. A regression Oracle caught earlier in this branch — passive replies echoing peer_address as the punch target — is explicitly called out in the site 3 comment to prevent re-introduction. Build + clippy -D warnings + workspace tests clean. End-to-end behavior will be verified in T7 (live cp + stderr inspection for real LAN IPs).
…unch payload
Replaces the two 127.0.0.1 hardcodes in peeroxide/src/swarm.rs with the
Node-parity helpers landed in c589937 + 5aacc5a:
- Site 4 (handle_server_handshake:1004): addresses4 in noise reply.
Replaced literal-construction block with
`self.dht.noise_addresses4(advertise_port).await`. The conditional
port logic (puncher_port when a passive holepunch is staged,
self.local_port otherwise) is preserved unchanged.
- Site 5 (commit_passive_holepunch:1332): local_punch_addrs stored
on the InFlightHolepunch entry. Replaced literal with
`setup.puncher.punch_addresses(setup.puncher_port)` (Algorithm B:
autoSample reflexive + LAN fallback).
Combined with the peeroxide-dht changes (commit 77d8100), all five
production hardcode sites are now eliminated. Grep verification:
grep -rE '"127\.0\.0\.1"\.to_string\(\)' \
peeroxide-dht/src/hyperdht.rs peeroxide/src/swarm.rs \
| grep -v "test\|\.rs:3[0-9]\{3\}"
returns zero matches.
Build + clippy -D warnings + workspace tests (40 suites, 0 failures)
clean. Live network + manual cp QA in T7 confirms real LAN IPs
appear in stderr.
Initial T2 implementation returned ONLY puncher.nat.addresses (the autoSample reflexive samples) when populated, falling back to LAN only when autoSample was empty. The live test_live_cp_send_recv_no_lan regressed because reflexive-only advertisement requires NAT hairpin on the receiver's router for same-host punching — many home routers don't support hairpin, so the punch fails after probe rounds complete. Node-parity (advertise nat.addresses) is correct for OFF-host punching, which is what reflexive samples enable. But Node also has a receiver- side same-host shortcut (clientAddress.host === serverAddress.host → bypass holepunch entirely) that we have not yet ported. Without that shortcut, same-host punching still goes through the holepunch flow and needs an address it can reach without hairpin. Combine reflexive + LAN: reflexive first (off-host hot path), LAN appended (same-host / same-LAN hairpin-free fallback). This is a deliberate divergence from strict Node parity, documented inline. Manual cp QA with PEEROXIDE_LOCAL_CONNECTION=false: probe reply addresses=3 verified=true socket_pool: holepunch probe received addr=192.168.1.25:50155 socket_pool: holepunch probe received addr=192.168.1.72:50155 punch successful from=192.168.1.25:50155 sha256 input=output = b2a10c63...07aae8 5/5 live tests pass in 35.60s. Build + clippy clean. Unit tests updated to assert the new combined-list shape.
Surfaces SwarmConfig.relay_through + relay_address as PEEROXIDE_FORCE_RELAY=<pubkey_hex>@<host:port> so operators who run or know a blind-relay node can force `peeroxide cp` to route through it. Mirrors the PEEROXIDE_LOCAL_CONNECTION pattern. Includes parser unit tests and an end-to-end wiring test gated #[ignore] (uses a bogus placeholder relay; verifies the env var plumbs through to the noise handshake regardless of whether the relay is reachable).
…ntion Tags 11 high-signal swarm/peer/holepunch/dht lifecycle sites with a reserved `peeroxide::_events::<subsystem>::<event>` target subtree so operators can filter for production-meaningful events independently of crate verbosity. Pairs the labeling with a relevel pass demoting per-round / per-packet / per-tick INFO sites to debug or trace so the default stream stays clean. Updates the default EnvFilter in main.rs to surface _events at every verbosity. Documents the convention in docs/src/appendices/tracing.md (registered in SUMMARY.md).
- libudx: expose UdxSocket::set_ttl/ttl (IP_TTL) so holepunch probes can actually use a low TTL, rather than discarding the computed value - peeroxide-dht: SocketPool::send_holepunch now calls set_ttl instead of silently no-op-ing (closes long-standing TTL-support TODO) - peeroxide-dht: Holepuncher now spawns a recv-adapter task per socket (primary + every birthday-attack socket), not just the primary, so a probe landing on a birthday socket correctly fires Connected (regression test: recv_adapter_covers_birthday_sockets) - peeroxide-dht: Nat::auto_sample_collects_target_samples test replaced its unimplemented!() stub with a real end-to-end loopback DHT test exercising ping_via_socket, reflexive sample collection, and puncher socket reply-forwarding - peeroxide-dht/lib.rs: replaced Wave-9 TODO module doc stubs with full documentation for holepuncher, io, peer, persistent, secretstream, secure_payload, socket_pool - version bump: libudx 1.3.1 -> 1.4.0, peeroxide-dht 1.4.0 -> 1.5.0 (minor only, no breaking API changes)
…tainer The dual-NAT holepunch Docker rig had drifted out of sync with the current CLI/build layout and was not runnable. Repair and redesign: - Replace the single `bootstrap` container (which ran 6 DHT nodes in-process via tests/node/nat-bootstrap.js) with 6 separate containers (dht-node-1..6) on the public network, each running one real DHT node and alternating Node.js (tests/node/dht-node.js, new) and Rust (`peeroxide node`) implementations. This is more realistic (each bootstrap peer gets its own address/process, matching how a real public DHT is composed of independent nodes) and exercises the Rust `peeroxide node` CLI as genuine mesh infrastructure, not just a test client. - Bootstrap topology: dht-node-1 is the genesis node (no bootstrap contacts); every other node bootstraps against the full list of all 5 other node addresses (not a star or chain). A star/chain topology was tried first and reliably converged to only closest=2 peers, because dht-rpc has no fast sub-second re-convergence after initial bootstrap and each secondary's table is capped by whatever the genesis node happened to know about at the moment it joined. Giving every node the full peer list fixes this (contacting a not-yet- started peer is a harmless timeout) and reliably converges to closest=5. - run-peer.sh: default BOOTSTRAP_HOST now points at dht-node-1; replaced the flat sleep with MESH_SETTLE_SECONDS (sender) / RECEIVER_SETTLE_SECONDS (receiver) delays to give the mesh time to cross-populate before peers start DHT operations. - Verified with 3 consecutive clean passes: closest=5 DHT convergence, real UDP hole-punch across the two NAT gateways, correct file transfer, and a passing exit status from run-nat-test.sh.
Adds a real blind-relay server implementation, closing the gap identified in RELAY_RESEARCH.md: peeroxide previously only implemented the client side of the blind-relay protocol. peeroxide-dht/src/blind_relay.rs: - BlindRelayServer/BlindRelaySession: protocol-only pairing engine (shared pairing table, session bookkeeping, configurable limits). Mirrors Node's blind-relay Server/BlindRelaySession/BlindRelayPair. - BlindRelayServerConfig: max_sessions, max_pairings_per_session, pairing_timeout, idle_session_timeout. Node's blind-relay has none of these (confirmed by source inspection) -- this is a peeroxide-specific hardening addition; defaults are generous so a default relay is, in practice, unthrottled like the Node reference. - RelayStats/RelayStatsSnapshot: counters named to mirror blind-relay-service's relay.stats fields. - 12 new unit tests covering matching, limits, timeouts, and a full two-BlindRelayClient-vs-two-BlindRelaySession end-to-end scenario. peeroxide-dht/src/relay_service.rs (new): - run_relay_server(): standalone accept-and-run entry point. Registers the relay's own identity via HyperDhtHandle::register_server so the DHT handshake router treats inbound PEER_HANDSHAKE requests as "handle locally"; finalizes each accepted connection into an encrypted control channel; wraps it in a BlindRelaySession; and on a matched pairing, creates the two raw UDX data-plane streams and bridges them with UdxStream::relay_to (blind, packet-level forwarding -- peeroxide never decrypts relayed application data). - Deliberately independent of peeroxide::Swarm (no topics/discovery/ retry bookkeeping needed for a relay) rather than a swarm.rs refactor. CLI (per user direction, both surfaces share one engine): - New `peeroxide relay` subcommand: standalone relay-only process. - `peeroxide node --relay`: opt-in courtesy relay alongside normal DHT routing duties. Both expose --max-sessions/--max-pairings-per-session/ --pairing-timeout/--idle-session-timeout and a deterministic --key-seed/--relay-key-seed option. Bug fixes uncovered while validating against a real relay (tightly coupled to this feature -- there was no relay server to test the existing client-side relay code against until now): - peeroxide/src/swarm.rs (create_server_relay_connection): reused the server's own local_stream_id (already advertised in the direct-connect handshake reply) as the blind-relay data-stream id too. Since that id comes from a crate-local counter independent of the one hyperdht.rs::connect_to uses internally for the control connection to the relay (both on the *same*, reused socket), the two could coincidentally collide, silently clobbering the control channel's UDX demux registration mid-handshake (observed as an immediate spurious "stream closed"). Fixed by minting the data-stream id from the same counter via a new peeroxide_dht::hyperdht::alloc_stream_id(). - libudx/src/native/stream.rs (process_incoming): the relay fast-path (packet forwarding once UdxStream::relay_to is configured) was checked *before* the firewall-hook gate (single-fire 4-tuple adoption). Wiring relay_to before either side of a pairing had received its first packet therefore permanently starved both hooks -- every packet, including the legitimate first one, took the relay short-circuit and never reached the hook, so neither stream's remote_addr was ever adopted and forwarding silently dropped every packet forever (both peers retransmitted to RTO exhaustion with zero bytes ever bridged in testing). Fixed by moving the relay fast-path check to after the firewall-hook gate, so the packet that fires a stream's hook is still forwarded on that same pass instead of being absorbed into normal (unread) stream processing. Docker test rig (tests/docker/docker-compose.relay.yml, run-relay-test.sh): - Adds a relay-rust service (peeroxide relay, fixed --key-seed) to the existing one-node-per-container public DHT mesh. - Forces both dual-NAT peers' cp send/cp recv traffic through it via PEEROXIDE_FORCE_RELAY (run-peer.sh gains FORCE_RELAY_PUBKEY/ FORCE_RELAY_ADDR env vars, off by default -- the plain NAT test is unaffected). - Verified: 2 consecutive clean passes (real dual-NAT peers bridged through the Rust relay, file transfer verified end-to-end), plus the existing plain NAT-holepunch test re-verified passing (no regression from the libudx process_incoming reordering). Not yet built (out of scope for this pass, tracked as follow-up): - Node.js-side relay/client interop scenarios in the Docker rig (a real Node blind-relay Server, and Node cp-equivalent clients) to prove cross-implementation wire compatibility in both directions -- only the Rust<->Rust-through-Rust-relay scenario was built and verified. - Idle-session-timeout enforcement (the config field and CLI flag exist; the sweep loop only currently enforces pairing_timeout). - Explicit teardown of bridged raw streams on unpair/session-close (they currently live for the process lifetime once bridged). Version bumps (minor/patch only, no breaking API changes): libudx 1.4.0 -> 1.4.1, peeroxide-dht 1.5.0 -> 1.6.0, peeroxide 1.3.1 -> 1.3.2, peeroxide-cli 0.2.1 -> 0.3.0. Also corrected long-stale intra-workspace path-dependency version strings in peeroxide/Cargo.toml and peeroxide-cli/Cargo.toml that had drifted behind the actual crate versions.
…y interop Follow-up to the blind-relay server implementation (b7404fa), addressing 3 tracked gaps. Node-precedent checked directly against tests/node/node_modules/blind-relay/index.js before implementing: 1. Stream teardown on unpair/session-close (exact 1:1 Node precedent): - BlindRelayServer tracks active (already-matched) pairings with a teardown channel (mark_active). unpair() now checks active pairings when a token isn't found pending, signaling teardown and returning a new UnpairOutcome::Destroyed -- mirrors blind-relay's _onunpair, which destroys an active stream found in this._streams when the token isn't in the pending table. - release_session() now also tears down any active pairings the closing session was part of -- mirrors _onclose destroying every stream in this._streams. - relay_service::bridge_matched_pairings keys its stream storage by token (HashMap, was an unkeyed Vec) so a specific pairing's raw UDX streams can be looked up and dropped on the teardown signal (dropping a UdxStream aborts its forwarding task). 2. Idle-session-timeout enforcement (NO Node precedent -- confirmed no idle/timeout concept anywhere in blind-relay's session code; this is a deliberate peeroxide-only hardening addition, documented as such everywhere it appears): - BlindRelayServer tracks per-session last-activity (touch_session, called on every pair/unpair) and exposes sweep_idle_sessions(), wired into relay_service::run_relay_server's existing periodic sweep task alongside sweep_expired_pairings(). - New SessionOutbound::Close lets the sweep signal a specific session's driver loop to exit cleanly. 6 new unit tests for these two items (30 total in blind_relay.rs). 3. Node.js relay interop (tests/docker/, tests/node/) -- partial: - Scenario A (real Rust `peeroxide cp` peers bridged by the real Node.js blind-relay Server, tests/node/blind-relay-server.js) -- PASSED. Proves our Rust blind-relay *client* is wire-compatible with Node's actual reference server, not just our own. Required adding --bootstrap/--key-seed args to blind-relay-server.js (it only supported the public bootstrap network before) and removing its stdin-'end'-triggered shutdown, which fired immediately under Docker (no open stdin) and killed the process right after startup, before it could accept any connection. - Scenario B (Node.js Hyperswarm peers bridged by relay-rust) -- NOT YET ACHIEVED. relay-cp-send.js announces successfully and relay-rust visibly participates in the resulting LOOKUP/FIND_PEER DHT traffic, but relay-cp-recv.js never discovers/connects to the sender within a 90s window, even after fixing a real bug (missing `await dht.fullyBootstrapped()` before constructing Hyperswarm in both new client scripts) and adding an app-level lookup-retry loop (Hyperswarm's own PeerDiscovery only retries a failed/empty lookup after a ~10-minute interval by default). Root cause not yet isolated -- tracked as a follow-up requiring direct dht.announce()/ dht.lookup() instrumentation in the real 6-node Docker mesh. - All interop artifacts are committed regardless (both compose overlays, both new Node client scripts, run-relay-interop-test.sh) since scenario A already depends on and validates most of the plumbing scenario B needs. Validation: cargo test --workspace (900 tests) + cargo clippy --workspace --all-targets clean. Re-verified both existing Docker tests (run-relay-test.sh, run-nat-test.sh) still pass after these changes. Version bump: peeroxide-dht 1.6.0 -> 1.7.0 (minor, additive only -- new pub fns/enum variants, no breaking changes).
…discover it Root cause of the relay-nodejs-interop failure (Node.js Hyperswarm peers bridged by our Rust blind-relay server): run_relay_server only called register_server(hash(pubkey)), a purely local marker telling the DHT layer to handle inbound PEER_HANDSHAKE requests targeting our identity. Nothing ever told the rest of the network the relay exists. Node's dht.connect(pubkey) resolves candidates via a LOOKUP-style findPeer() query for an announced record on hash(pubkey), not a raw closest-node walk. Confirmed empirically: dht.findPeer(hash(relayPubkey)) returned zero replies against our relay, so Node's client never even attempted a PEER_HANDSHAKE to it (relay-rust's logs showed zero PEER_HANDSHAKE arrivals during a fresh isolated connect attempt), despite raw FIND_NODE correctly returning the relay as a routing candidate. Node's own dht.createServer()/server.listen() internally starts an Announcer for the server's own target for exactly this reason. peeroxide already has this pattern for Swarm servers (peer_discovery.rs's self_announce on hash(pk)), but the standalone relay service bypasses Swarm entirely and never announced itself. Fix: after register_server, spawn a background task that calls dht.announce(hash(pubkey), key_pair, relay_addresses) once at startup and every ~10 minutes thereafter (jittered), mirroring peer_discovery::do_refresh's self-announce cadence. Verified: run-relay-interop-test.sh Scenario B (Node Hyperswarm peers bridged by relay-rust) now passes. Re-ran Scenario A, run-relay-test.sh, and run-nat-test.sh — all still green. Full workspace test suite (900 tests) and clippy clean. peeroxide-dht: 1.7.0 -> 1.7.1 (bugfix, no API changes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds Unreleased entries to libudx, peeroxide, peeroxide-dht, and peeroxide-cli CHANGELOGs documenting the blind-relay server feature (BlindRelayServer, relay_service, CLI surfaces), idle-timeout/teardown hardening, the relay self-announce fix, and the two real bugs fixed along the way (UDX stream-id collision, firewall-hook/relay-fast-path ordering). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ICY.md RelayStats is a pub struct with pub atomic-counter fields, constructed only via Default::default() and never struct-literal'd or destructured outside the crate. Per VISIBILITY_POLICY.md's Event/Result role default (and its own RelayStatsSnapshot counterpart, which already carries the attribute), it should be #[non_exhaustive] so future counter fields can be added additively without a breaking change. No valid Handle-role exemption applies since its fields are public, not opaque. peeroxide-dht: 1.7.1 -> 1.7.2 (non-breaking, adds a forward-compat guard rail). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rkflow VISIBILITY_POLICY.md existed but was never referenced from AGENTS.md or CONTRIBUTING.md, so applying it depended on someone remembering to ask for it explicitly (as happened on this PR's RelayStats fix). Closes that gap: - AGENTS.md: new subsection under the API Breaking Change Policy directing that any new pub type/function/module must be checked against VISIBILITY_POLICY.md in the same change, not as a follow-up. - CONTRIBUTING.md: added an explicit PR-checklist item, plus a pointer from the API Stability section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This branch spans two major bodies of work:
run-nat-test.sh).peeroxide-dht::blind_relay::BlindRelayServer+relay_service::run_relay_server), CLI surfaces (peeroxide relay,peeroxide node --relay), idle-session-timeout/stream-teardown hardening, and end-to-end Node.js interop proof in both directions.Blind-relay server details
BlindRelayServer/BlindRelaySession: protocol-only pairing engine mirroring Node'sblind-relayServer/BlindRelaySession/BlindRelayPair.BlindRelayServerConfig: configurable session/pairing limits and timeouts — a peeroxide-specific hardening addition (Node's reference has none), with generous non-limiting defaults so a default relay behaves like the unthrottled Node reference in practice.relay_service::run_relay_server: real transport wiring usingUdxStream::relay_tofor blind, packet-level forwarding (peeroxide never decrypts relayed application data). Deliberately independent ofpeeroxide::Swarm._onclose/_onunpair).hash(pubkey)) to the DHT at startup + periodic refresh, mirroring Node'sServer.listen()internalAnnouncer. Without this, a Node.jshyperdhtclient'sdht.connect(pubkey)(a LOOKUP-stylefindPeerquery for an announced record, not a raw closest-node walk) could never discover the relay and failed withPEER_NOT_FOUND— confirmed empirically via isolatedfindPeer/findNodeprobes and relay-side log inspection.swarm.rs's relay-client path, and alibudxprocess_incomingordering bug where the relay fast-path starved firewall-hook address adoption.Testing
cargo test --workspace --lib --bins— 900 tests passingcargo clippy --workspace --all-targets -- -D warnings— cleancargo doc --workspace --no-deps— clean except 16 pre-existing unrelated doc-link warnings inpeeroxide-cli's chat/deaddrop modules (verified present before this branch's changes, not introduced here)Versions
libudx: 1.4.0 -> 1.4.1 (bugfix)peeroxide-dht: 1.3.1 -> 1.7.1 (feature + bugfixes)peeroxide: 1.2.0 -> 1.3.2 (bugfix)peeroxide-cli: 0.2.1 -> 0.3.0 (new subcommand/flags)No breaking public API changes.
Checklist